package com.hup.vd.service;

import com.alibaba.fastjson.JSON;
import com.hup.utils.commons.DateBuilder;
import com.hup.utils.commons.exception.AppCommonException;
import com.hup.utils.commons.start.SimpleExitHandler;
import com.hup.utils.commons.start.SpringContext;
import com.hup.utils.commons.start.VersionConfig;
import com.hup.utils.commons.thread.ThreadUtil;
import com.hup.utils.swing.ImageUtil;
import com.hup.vd.constant.TaskConstant.TaskState;
import com.hup.vd.m3u8.M3u8Data;
import com.hup.vd.m3u8.M3u8DecryptUtil;
import com.hup.vd.m3u8.M3u8Util;
import com.hup.vd.m3u8.M3u8Util.M3u8DataException;
import com.hup.vd.model.config.AppConfig;
import com.hup.vd.model.config.DownloadConfig;
import com.hup.vd.model.config.TaskInfo;
import com.hup.vd.service.m3u8.HtmlUrlHandler;
import com.hup.vd.service.m3u8.HtmlUrlHandler.HtmlInfo;
import com.hup.vd.util.CommonUtil;
import com.hup.vd.util.RestTemplateUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import javax.swing.SwingUtilities;
import java.awt.Color;
import java.awt.Image;
import java.awt.Toolkit;
import java.awt.image.MemoryImageSource;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.Semaphore;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;

/**
 * m3u8视频下载类;
 * .根据url下载m3u8文件
 * .下载并合并m3u8内的片段
 * 关于下载时中断网络:必须要设置timeout
 * .RestTemplate和FileUtils都一样
 * .请求发起后,正在接收数据时断网:如果不设置timeout,则会一直阻塞线程!!即使重连网络后也不一定能解除阻塞!!!
 * .请求发起前就没网,会'马上'异常"无法解析域名",即使没设置timeout
 *
 * @author hugan
 * @date 2022/1/12
 */
@Slf4j
@Component
public class M3u8DownloadService {

    private static final int CONNECT_TIMEOUT = 20000;
    private static final int READ_TIMEOUT = 50000;

    private final DownloadConfig downloadConfig;
    private final int parallelPieceDownloadCount;

    private final HtmlUrlHandler htmlUrlHandler;
    private final SimpleExitHandler exitHandler;
    private final RestTemplate restTemplateUrlFile;
    private final FileDownloadService fileDownloadService;

    /**
     * {@link VersionConfig#env}
     * 开发字段
     * 正常环境:null
     */
    private String env;

    public M3u8DownloadService(AppConfig appConfig, SimpleExitHandler exitHandler) {
        downloadConfig = appConfig.getDownloadConfig();
        parallelPieceDownloadCount = downloadConfig.parallelPieceDownloadCount;
        htmlUrlHandler = SpringContext.getBean(HtmlUrlHandler.class);
        this.exitHandler = exitHandler;
        fileDownloadService = SpringContext.getBean(FileDownloadService.class);
        HttpComponentsClientHttpRequestFactory factory = RestTemplateUtil.createRedirectFactory();
        factory.setConnectTimeout(CONNECT_TIMEOUT);
        factory.setReadTimeout(READ_TIMEOUT);
        restTemplateUrlFile = new RestTemplate(factory);

        SpringContext.executeIfExist(VersionConfig.class, versionConfig -> env = versionConfig.env);
    }

    public void doDownload(TaskInfo task, ThreadPoolExecutor pieceDownloadExecutor, TaskViewUpdater taskViewUpdater) throws Throwable {
        log.info("开始下载={}", task);
        CancelState cancelState = () -> task.isWaitToPause() || exitHandler.isExiting();
        DownloadData data = new DownloadData(task, taskViewUpdater, cancelState);

        if (!task.getUrl().endsWith(".m3u8")) {
            //如果不是m3u8地址, 就用chromedriver解释网页 并替换成m3u8地址; 只会被成功执行一次
            String htmlUrl = task.getUrl();
            log.info("抓取html中的m3u8, {}", htmlUrl);
            HtmlInfo htmlInfo = htmlUrlHandler.getM3u8Url(htmlUrl, cancelState, task);
            if (htmlInfo == null) {
                //返回null: 任务被取消; 如果是抓取失败 会在前面抛异常
                log.info("任务被取消");
                return;
            }
            task.setUrl(htmlInfo.m3u8Url);
            task.setHtmlTitle(htmlInfo.htmlTitle);
            task.setHtmlUrl(htmlUrl);//前面成功后才赋值
            task.save();
            SwingUtilities.invokeLater(() -> data.taskViewUpdater.updateTaskPanel(true));
        }
        if (cancelState.isCanceled()) return;

        //获取和解析m3u8
        try {
            M3u8Data m3u8Data;
            Consumer<HttpHeaders> headersSetter = headers -> {
                headers.set(HttpHeaders.ACCEPT_ENCODING, "gzip, deflate, br");
                headers.set(HttpHeaders.USER_AGENT, downloadConfig.getUserAgent());
                if (task.getHtmlUrl() != null) {
                    String webHost = CommonUtil.getWebHost(task.getHtmlUrl());
                    log.debug("REFERER={}", webHost);
                    //意义不大,有些网站的视频 是多次重定向 不同REFERER的
                    headers.set(HttpHeaders.REFERER, webHost);
                }
            };
            if (!data.m3u8File.exists()) {
                log.info("网络获取m3u8");
                m3u8Data = M3u8Util.parseFromUrl(restTemplateUrlFile, task.getUrl(), headersSetter);
                FileUtils.writeStringToFile(data.m3u8File, m3u8Data.getSourceText(), StandardCharsets.UTF_8);
                log.info("m3u8保存成功");
                if (m3u8Data.getDecryptMethod() != null) {
                    //保存解密字段
                    task.setDecryptMethod(m3u8Data.getDecryptMethod());
                    task.setAesKeyBase64(m3u8Data.getAesKeyBase64());
                    task.setAesIvHex(m3u8Data.getAesIvHex());
                    task.save();
                }
            } else {
                log.info("读取本地m3u8");
                String m3u8Str = FileUtils.readFileToString(data.m3u8File, StandardCharsets.UTF_8);
                /*
                传入null的hostUrl原因
                .本文件是前面代码生成的,生成时已经补全了url
                .不能使用 task.getUrl(), 因为它可能是302重定向前的url
                 */
                m3u8Data = M3u8Util.parseMediaPlaylistData(m3u8Str, null, restTemplateUrlFile, headersSetter);
                //下载m3u8时 解释到的加密字段
                m3u8Data.setAesKeyBase64(task.getAesKeyBase64());
                m3u8Data.setAesIvHex(task.getAesIvHex());
            }
            m3u8Data.setSourceText(null);
            data.onGetM3u8Data(m3u8Data);
            SwingUtilities.invokeLater(() -> data.taskViewUpdater.updateTaskPanel(false));
        } catch (M3u8DataException e) {
            throw e;
        } catch (Exception e) {
            throw new AppCommonException("m3u8解析异常:" + e.getMessage());
        }

        //下载片段
        try {
            if (cancelState.isCanceled()) return;
            downloadPieces(data, pieceDownloadExecutor);
        } catch (InterruptedException e) {
            String msg = "Interrupted:" + data.currentInterruptAble + ", " + e.getMessage();
            log.error("{}", msg, e);
            Thread.currentThread().interrupt();
            throw new AppCommonException(msg);
        }
        //if (true) throw new IllegalArgumentException("测试");

        //合并片段
        if (cancelState.isCanceled()) return;

        log.info("开始合并片段");
        task.setTaskState(TaskState.MERGE);
        ArrayList<String> names = new ArrayList<>(data.total);
        for (int i = 0; i < data.total; i++) names.add(data.getPieceFileName(i));
        M3u8Util.mergeTs(data.tempDir, names, new File(task.getFilePath()));
        if (!"dev".equals(env)) {
            data.deleteTempDir();
        }

        //执行到本方法末端时,任务才算是已完成的
        //更新状态
        task.setFinishTime(new Date());
        task.setFileSizeStr(FileUtils.byteCountToDisplaySize(new File(task.getFilePath()).length()));
        task.setFinished(true);
    }

    /**
     * 多线程下载片段
     *
     * @throws InterruptedException queue和countDownLatch时可能会抛出
     * @throws Throwable 一般是pieceDownloadExecutor的子线程抛出的异常
     */
    private void downloadPieces(DownloadData data, ThreadPoolExecutor pieceDownloadExecutor) throws Throwable {
        log.info("开始下载片段={}", data.task);
        //限制单个任务中,并行下载的片段数;pieceDownloadExecutor可以是多个任务并行共用的
        int threadCount = parallelPieceDownloadCount;
        if (threadCount < 1) throw new IllegalArgumentException("任务子线程数错误:" + threadCount);
        Semaphore semaphore = new Semaphore(threadCount);
        //构建待下载队列
        LinkedBlockingQueue<Integer> queue = new LinkedBlockingQueue<>();
        for (int i = 0; i < data.total; i++) {
            String pieceName = data.getPieceFileName(i);
            File targetPiece = new File(data.tempDir, pieceName);
            if (targetPiece.exists()) {
                // 此处实现了简单的断点下载:即每次启动任务后,不用重新下载已有片段
                data.finishPiece(i);
            } else {
                queue.offer(i);
            }
        }
        SwingUtilities.invokeLater(() -> data.taskViewUpdater.updateTaskPanel(false));
        log.info("即将下载, 进度={}/{}", data.finishCounter.get(), data.total);

        /*
        遍历待下载队列
        下载失败策略:
            .片段下载超时后,会被放回到队列末尾,或失败次数超过阈值后抛出异常
            .片段重试的最小间隔,≥config.pieceRetryWaitTime
         */
        while (data.finishCounter.get() != data.total) {
            if (data.isCancelTask()) break;
            if (queue.isEmpty()) {
                /*
                队列为空时,代表任务快结束了,要等待最后的子线程的下载结果
                    .全都下载成功,finishCounter==total,退出while
                    .下载失败,index重新被添加到队列,while继续
                只要有新的子线程被释放,就马上唤醒本线程
                    方法:semaphore等待要获取的信号量=当前可用线程+1
                 */
                int availablePermits = semaphore.availablePermits();
                int waitThreadCount = Math.min(threadCount, availablePermits + 1);
                log.info("待下载队列为空,开始等待, 下载中={}, 进度={}/{}, ", threadCount - availablePermits, data.finishCounter.get(), data.total);
                semaphore.acquire(waitThreadCount);
                semaphore.release(waitThreadCount);
                //子线程结束,回到while循环,判断finishCounter
                continue;
            }
            //在主线程.阻塞的take
            int index = queue.take();
            String pieceName = data.getPieceFileName(index);

            //log.info("等待入列={}", pieceName);
            data.currentInterruptAble = pieceName;
            semaphore.acquire();
            //log.info("成功入列={}", pieceName);

            //有异常或任务被取消
            if (data.isCancelTask()) {
                semaphore.release();//也要释放semaphore
                break;
            }
            /*
            sleep作用
                避免很偶然的[execute任务溢出异常]
                原因: 子线程semaphore.release后,线程仍未结束, 前面的semaphore.acquire()就马上放行了
             */
            ThreadUtil.sleep(5);
            //启动子线程
            pieceDownloadExecutor.execute(() -> {
                PieceTimeoutData timeoutData = data.mapPieceTimeoutData.get(index);
                try {
                    if (timeoutData != null) {
                        //同一个片段,上次下载超时后,需要等待足够时间才去重试
                        long sleepTime = downloadConfig.pieceRetryWaitTime - (System.currentTimeMillis() - timeoutData.lastRequestTime);
                        if (sleepTime > 0) {
                            log.info("超时重试 {}, 等待{}ms", pieceName, sleepTime);
                            ThreadUtil.sleep(sleepTime);
                        }
                    }
                    if (data.isCancelTask()) return;
                    downloadPieceFile(data, index, pieceName);
                } catch (Throwable e) {
                    if (e instanceof IOException) {
                        //IO异常
                        if (timeoutData == null) timeoutData = new PieceTimeoutData();
                        timeoutData.timeoutCount++;
                        timeoutData.lastRequestTime = System.currentTimeMillis();
                        String desc;
                        if (e.getMessage().contains("timed out")) {
                            desc = "下载超时 ";
                        } else {
                            /*
                            java.net.SocketException: Connection reset
                             */
                            desc = e.getMessage();
                        }
                        String msg = desc + ", " + pieceName + ", " + timeoutData.timeoutCount + "次";
                        log.error("{}", msg);
                        if (timeoutData.timeoutCount >= downloadConfig.pieceRetryCount) {
                            //抛出异常
                            data.pieceErr.set(new AppCommonException(msg));
                            data.onPieceErr(index);
                        } else {
                            //将片段添加到队列末尾,记录超时次数
                            queue.offer(index);
                            data.mapPieceTimeoutData.put(index, timeoutData);
                        }
                    } else {
                        //其他异常
                        log.info("出现其他异常");
                        data.pieceErr.set(e);
                        data.onPieceErr(index);
                    }
                } finally {
                    //任务结束,释放semaphore
                    semaphore.release();
                }
            });
        }

        /*
        等待子线程结束
        这里只有出现异常或用户暂停任务时,才要等待
        如果是正常下载成功,semaphore.acquire是不用等待的
         */
        int availablePermits = semaphore.availablePermits();
        data.currentInterruptAble = "等待子线程结束";
        log.info("等待{}个子线程结束.st", threadCount - availablePermits);
        semaphore.acquire(threadCount);
        semaphore.release(threadCount);//不释放会不会内存泄露??

        //如果有异常则抛出,是因为异常而终止的任务
        if (data.pieceErr.get() != null) throw data.pieceErr.get();
    }

    /**
     * 下载片段文件
     */
    private void downloadPieceFile(DownloadData data, int index, String pieceName) throws IOException, M3u8DataException {
        long st = System.currentTimeMillis();
        log.debug("下载={}/{}, {}", index + 1, data.total, pieceName);

         /*
        下载时,使用临时文件名;以"_temp"开头,方便在文件夹直接看出正在下载的文件
        下载完成后还原名称,实现确认完成的逻辑
         */
        File temp = new File(data.tempDir, "_temp_" + pieceName);
        URL url = new URL(data.m3u8Data.getUrls().get(index));
        Consumer<URLConnection> headersSetter = connection -> {
            connection.setRequestProperty(HttpHeaders.ACCEPT_ENCODING, "gzip, deflate, br");
            connection.setRequestProperty(HttpHeaders.USER_AGENT, downloadConfig.getUserAgent());
        };

        //下载文件
        if ("dev".equals(env)) {
            log.info("模拟下载={}", temp.createNewFile());
            ThreadUtil.sleep(ThreadLocalRandom.current().nextInt(2800, 5000));
        } else {
            fileDownloadService.copyURLToFile(url, temp, CONNECT_TIMEOUT, READ_TIMEOUT, headersSetter,
                    data.cancelState, data.task.getNetSpeedCounter());
        }

        if (data.cancelState.isCanceled()) return;//任务取消, 文件是残缺的

        //下载完成
        File targetPiece = new File(data.tempDir, pieceName);
        if (data.m3u8Data.getDecryptMethod() != null) {
            //log.info("需要解密");//test
            File decryptFile = new File(temp.getAbsolutePath() + ".de");
            String aesKeyBase64 = data.m3u8Data.getAesKeyBase64();
            String aesIvHex = data.m3u8Data.getAesIvHex();
            M3u8DecryptUtil.decryptAES128(temp, decryptFile, aesKeyBase64, aesIvHex);

            boolean rename = decryptFile.renameTo(targetPiece);
            if (!rename) throw new IOException("重命名异常:" + temp);
            boolean delete = temp.delete();
            if (!delete) log.warn("临时文件删除失败? ={}", temp.getAbsolutePath());
        } else {
            boolean rename = temp.renameTo(targetPiece);
            if (!rename) throw new IOException("重命名异常:" + temp);
        }
        log.debug("下载完成={}/{}, {}, 耗时={}", index + 1, data.total, pieceName, DateBuilder.spanChar(System.currentTimeMillis() - st, true));
        data.finishPiece(index);

        //更新[任务信息]
        SwingUtilities.invokeLater(() -> data.taskViewUpdater.updateTaskPanel(false));
    }

    private static class DownloadData {
        private final TaskInfo task;
        private final TaskViewUpdater taskViewUpdater;
        private final CancelState cancelState;
        private final File tempDir;
        private final File m3u8File;
        private M3u8Data m3u8Data;
        private int total;
        /**
         * 当前可能会有InterruptedException的步骤名
         */
        private String currentInterruptAble;
        /**
         * 已下载的片段数
         */
        private final AtomicInteger finishCounter;
        /**
         * 片段文件名前缀
         * 由于ffmpeg,所以不支持中文
         */
        private final String pieceFilePrefix;
        /**
         * 实现在主线程上抛出[子线程的异常]
         */
        private final AtomicReference<Throwable> pieceErr = new AtomicReference<>();
        /**
         * map[片段,下载超时数据]
         */
        private final ConcurrentHashMap<Integer, PieceTimeoutData> mapPieceTimeoutData;

        public DownloadData(TaskInfo task, TaskViewUpdater taskViewUpdater, CancelState cancelState) {
            this.task = task;
            this.taskViewUpdater = taskViewUpdater;
            this.cancelState = cancelState;
            File targetFile = new File(task.getFilePath());
            tempDir = task.getTempDirFile();
            if (!tempDir.exists()) {
                if (!tempDir.mkdirs()) {
                    throw new AppCommonException("创建临时目录异常:" + tempDir.getAbsolutePath());
                } else {
                    //复制任务文件到临时目录,本程序不会用到此文件,类似迅雷保存的[.torrent]隐藏文件
                    File taskFileCopy = new File(tempDir, "_" + task.getShortName() + TaskInfo.EXT);
                    try {
                        String str = JSON.toJSONString(task, true);
                        FileUtils.write(taskFileCopy, str, StandardCharsets.UTF_8);
                    } catch (IOException e) {
                        throw new AppCommonException("写入任务文件异常:" + taskFileCopy.getAbsolutePath());
                    }
                }
            }
            m3u8File = new File(tempDir, "_" + targetFile.getName() + ".m3u8");
            finishCounter = new AtomicInteger();
            pieceFilePrefix = task.getUid() + "_";
            mapPieceTimeoutData = new ConcurrentHashMap<>();
            task.getNetSpeedCounter().reset();
        }

        public void onGetM3u8Data(M3u8Data m3u8Data) {
            this.m3u8Data = m3u8Data;
            total = m3u8Data.getUrls().size();
            task.setProgressMax(total);
            task.setProgressValue(0);

            //初始化进度图
            if (task.getProgressImage() != null) task.getProgressImage().flush();

            //长宽比例恒等于10/1, 公式推算略
            int imgWidth = (int) Math.sqrt(total * 10);
            task.setProgressImageWidth(imgWidth);
            int imgHeight = total / imgWidth;
            if ((total % imgWidth) > 0) imgHeight++;
            //log.info("图像={}x{}, total={}", imgWidth, imgHeight, total);
            int[] argbs = new int[imgWidth * imgHeight];
            for (int i = 0; i < total; i++) argbs[i] = Color.WHITE.darker().getRGB();
            Image image = Toolkit.getDefaultToolkit().createImage(new MemoryImageSource(imgWidth, imgHeight, argbs, 0, imgWidth));
            task.setProgressImage(ImageUtil.getImage(image));
        }

        public String getPieceFileName(int index) {
            return pieceFilePrefix + index + ".ts";
        }

        public boolean isCancelTask() {
            return pieceErr.get() != null || cancelState.isCanceled();
        }

        /**
         * 下载成功
         *
         * @param index 片段index
         */
        public void finishPiece(int index) {
            int value = finishCounter.incrementAndGet();
            task.setProgressValue(value);
            mapPieceTimeoutData.remove(index);
            //更新进度图
            int imgWidth = task.getProgressImageWidth();
            int x = index % imgWidth;
            int y = index / imgWidth;
            //log.info("index={}, coor=[{},{}]", index, x, y);
            task.getProgressImage().setRGB(x, y, Color.GREEN.getRGB());
        }

        public void onPieceErr(int index) {
            //更新进度图
            int imgWidth = task.getProgressImageWidth();
            int x = index % imgWidth;
            int y = index / imgWidth;
            //log.info("index={}, coor=[{},{}]", index, x, y);
            task.getProgressImage().setRGB(x, y, Color.RED.getRGB());
        }

        public void deleteTempDir() {
            if (!tempDir.exists()) return;
            try {
                FileUtils.deleteDirectory(tempDir);
            } catch (IOException e) {
                throw new AppCommonException("删除失败:" + tempDir.getAbsolutePath());
            }
        }

    }

    private static class PieceTimeoutData {
        /**
         * 超时失败次数
         */
        private int timeoutCount;
        /**
         * 上次超时后的时间
         */
        private long lastRequestTime;
    }

    public interface TaskViewUpdater {
        /**
         * 更新[任务信息]面板
         *
         * @param updateAll true-更新全部字段, false-更新图像
         */
        void updateTaskPanel(boolean updateAll);
    }

}
